import os.path
from http.client import responses as http_responses
from inspect import getfullargspec, isclass
from bodhi.wsgi import HttpRequest, HttpResponse, HttpError
from threading import currentThread
import traceback
from bodhi.utils.datastructures import classproperty, ObjectDict, camelcase_to_underscore
import logging
from bodhi.filters.csrf import csrf_tag
import posixpath
from importlib import import_module
import datetime
from functools import partial
from itertools import chain
from hashlib import md5

__all__ = ('View', 'Dispatcher', 'find_views', 'serve_static_file')

_views = {}

class View:
    SUPPORTED_METHODS = ("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS")
    _expose_ = False
    
    def __init__(self, dispatcher, request, response, data):
        self.dispatcher = dispatcher
        self.request = request
        self.response = response
        self.arguments = request.arguments
        self.data = data

    @classproperty
    def current(cls):
        return _views[currentThread()]

    def redirect(self, uri, permanent=False):
        # always use an absolute URI in the location header in the response. This is required by RFC 2616, section 14.30.
        assert isinstance(uri, str)
        self.response.redirect(self.request.build_absolute_uri(uri), permanent)

    def redirect_to(self, route_name=None, **kwargs):
        uri = self.uri_for(route_name, **kwargs)
        # always use an absolute URI in the location header in the response. This is required by RFC 2616, section 14.30.
        self.response.redirect(self.request.build_absolute_uri(uri))
    
    template_file_ext = '.html'

    def render(self, template_name=None, layout=None, **kwargs):
        self.response.write(self.render_to_string(template_name, layout, **kwargs))

    def render_to_string(self, template_name=None, layout=None, **kwargs):
        args = self._get_template_context()
        args.update(kwargs)

        template_path = self._get_template_path(template_name)
        t = self._get_template_loader().load(template_path)
        return t.generate(**args)

    def _get_template_loader(self):
        raise NotImplementedError('_get_template_loader')

    def _get_template_path(self, template_name):
        if template_name and template_name.startswith('/'):
            template_name = template_name[1:]
        else:
            # module, view have already been lower-cased.
            template_name = self.data['module'] + '/' + (template_name or self.data['view'])
        if not posixpath.splitext(template_name)[1]:
            template_name += self.template_file_ext
        return template_name

    def _get_template_context(self):
        return dict(
            view=self,
            request=self.request,
            #locale=self.locale,
            #_=self.request.locale.translate,
            csrf_tag=lambda: csrf_tag(self),
            uri_for=self.uri_for,
        )

    def uri_for(self, route_name=None, **values):
        return self.dispatcher.uri_for(values, self.data, route_name=route_name)

    @property
    def user(self):
        if hasattr(self, '_user'):
            return self._user
        user = self._get_auth_provider().authenticate(self)
        self._user = user
        return user

    def _get_auth_provider(self):
        raise NotImplementedError('_get_auth_provider')
    
    def _get_filters(self):
        # if _filters_ object has a '__get__' attribute, then it's a property.
        return chain.from_iterable([list_.__get__(self) if hasattr(list_, '__get__') else list_
                for list_ in [t.__dict__.get('_filters_') for t in type(self).__mro__]
                if list_])

    def process(self):
        if self.request.method not in self.SUPPORTED_METHODS:
            raise HttpError(405)

        m = getattr(self, self.request.method, None)
        if m is None:
            raise HttpError(405, '{}.{}: {} method not allowed.'.format(
                self.__module__, self.__class__.__name__, self.request.method))

        args = self._get_method_arguments(m)
        
        filters = self._get_filters()
        filters = sorted(filters, key=lambda f: f.order, reverse=True)

        action = partial(m, **args)
        for f in filters:
            action = partial(f, view=self, next=action)

        return action()

    def _get_method_arguments(self, method):
        while hasattr(method, '__wrapped__'):
            method = method.__wrapped__

        spec = getfullargspec(method)

        if spec.varargs or spec.varkw or spec.kwonlyargs:
            raise ValueError('{}.{}: varargs, varkw and kwonlyargs are not supported.'.format(
                    self.__class__.__name__, method.__name__))

        args = spec.args
        annotations = spec.annotations
        values = dict(zip(reversed(args), reversed(spec.defaults))) if spec.defaults else {}

        arguments = self.request.arguments

        for name in args[1:]:
            query_values = arguments.getlist(name)
            if query_values is None:
                query_values = tuple()
            type_ = annotations.get(name)
            if type_: 
                assert not isinstance(type_, (tuple, dict)), 'only support primitive types and [].'
                try:
                    if isinstance(type_, list):
                        type_ = type_[0]
                        values[name] = tuple(type_(value) for value in query_values)
                    elif query_values:
                        values[name] = type_(query_values[-1])
                except Exception as e:
                    err = 'argument type conversion error ({0}.{1}):\n  method: {2}\n  value: {3}\n\n{4}'.format(
                            type(self).__module__, 
                            type(self).__name__, 
                            self.request.method, 
                            query_values, 
                            e)
                    raise HttpError(404, err)
            elif query_values:
                values[name] = query_values[-1] # value default to str type

        if len(args) - 1 != len(values):
            raise HttpError(404, 'expected arguments: {2} on "{0}.{1}", got: {3}'.format(
                type(self).__module__, 
                type(self).__name__, 
                args, 
                tuple(values.keys())))
        return values


class Dispatcher:
    view_modules = None
    template_loader = None
    error_view = ('errors', 'index')

    def __init__(self, mapper, modules, debug=False):
        self.mapper = mapper
        self.modules = modules
        self.uri_cache = {}
        self.debug = debug

    def uri_for_cached(self, values, ref_values=None, *, route_name=None, cache_key=None):
        assert cache_key
        try:
            return self.uri_cache[cache_key]
        except:
            return self.uri_cache.setdefault(cache_key, 
                    self.uri_for(values, ref_values, route_name=route_name))

    def uri_for(self, values, ref_values=None, *, route_name=None):
        if self.debug:
            if ref_values is None:
                ref_values = {}

            module = values.get('module') or ref_values.get('module')
            if module not in self.modules:
                logging.warning('uri_for: unavailable module %s.', module)
            else:
                view = values.get('view') or ref_values.get('view')
                if view not in self.modules[module]:
                    logging.warning('uri_for: unavailable view %s.%s.', module, view)

        # do not check against constraints in production mode, if route_name is known.
        # if route_name is None, check will be ignored.
        return self.mapper.reverse(values, ref_values, 
                route_name=route_name, check=self.debug)

    def begin_request(self, request):
        pass
    
    def end_request(self, request, response):
        pass

    def __call__(self, environ, start_response):
        try:
            request = HttpRequest(environ)
            self.begin_request(request)

            try:
                request.parse()

                match_data = self.mapper.match(request.path)
                if match_data is None:
                    raise HttpError(404, 'no match route')

                try:
                    match_data['module'] = match_data['module'].lower()
                    match_data['view'] = match_data['view'].lower()
                except (KeyError, AttributeError):
                    # programming error: route mapper config error
                    raise ValueError('module or view parameter missing.')

                dict.update(request.arguments, 
                        {k: [v] for k, v in match_data.items() if v is not None})
                response = self.call_view(request, match_data)

            except HttpError as e:
                logging.warning('HTTP Error %s (%s): %s', 
                        e.status_code, 
                        request.remote_ip,
                        request.path) 
                response = self.handle_error(request, e)
            except Exception as e:
                logging.error('Internal Server Error (%s): %s',
                        request.remote_ip,
                        request.path,
                        exc_info=e) 
                response = self.handle_error(request, e)

            self.fix_response(request, response)

            status_text = http_responses[response.status_code]

            status = str(response.status_code) + ' ' + status_text
            headers = [(k, v) for k, v in response.headers.items()]
            for cookie in response.cookies.values():
                headers.append(("Set-Cookie", cookie.OutputString()))
        except:
            #should not happen, can do nothing here, just log it and show error.
            logging.exception('programming error')
            status, headers, response = '500 Internal Server Error', [], HttpResponse(b'Server Error')
        finally:
            self.end_request(request=request, response=response)
            request.close() # close upload tempfile if used.

        # if Content-Length not set, cherrypy server will automatically use 
        # chunked transfer encoding for HTTP/1.1, and close connection for 
        # HTTP/1.0.  

        # cherrypy server sends http headers in a tcp package, then a package every
        # chunk, finally a package for the ending chunk '0\r\n\r\n'. 
        # it seems better to send all in one package for small responses.

        start_response(status, headers)
        return response

    def call_view(self, request, match_data):
        try:
            module = self.modules[match_data['module']]
        except KeyError:
            raise HttpError(404, 'module "{0}" not found.'.format(match_data['module']))

        try:
            view_class = module[match_data['view']]
        except KeyError:
            raise HttpError(404, 'view "{0}" not found.'.format(match_data['view']))

        view = view_class(self, request, HttpResponse(), match_data)
        try:
            t = currentThread()
            _views[t] = view
            view.process()
            return view.response
        finally:
            del _views[t]

    def handle_error(self, request, e):
        status_code = e.status_code if isinstance(e, HttpError) else 500
        if self.debug:
            return HttpResponse(status_code=status_code,
                    content=traceback.format_exc(),
                    content_type='text/plain; charset=' + HttpResponse.encoding)
        else:
            return self.call_view(request, 
                    {
                        'module': self.error_view[0], 
                        'view': self.error_view[1], 
                        'status_code': status_code})

    def fix_response(self, request, response):
        """
        Removes the content of responses for HEAD requests, 1xx, 204 and 304
        responses. Ensures compliance with RFC 2616, section 4.3.
        """
        status_code = response.status_code
        if status_code in (204, 304) or 100 <= status_code < 200:
            response.content = b''

            headers = response.headers
            del headers['Content-Length']
            if status_code == 304:
                del headers['Content-Type']
        elif request.method == 'HEAD':
            response.content = b''
        else:
            response.set_header('Content-Length', str(len(response.content)))


def find_views(dir, pkg_name):
    if isinstance(dir, str):
        import glob
        view_modules = [os.path.basename(f)[:-3] for f in glob.glob(os.path.join(dir, 'views', '[!_]*.py'))]
    else:
        assert isinstance(dir, dict)
        view_modules = dir

    result = {}

    for module_name in view_modules:
        m = import_module(pkg_name + '.views.' + module_name)
        klasses = set(obj for name, obj in m.__dict__.items()
            if isclass(obj) and issubclass(obj, View) and obj.__dict__.get('_expose_', True))
        klasses = {camelcase_to_underscore(cls.__name__[:-4]): cls 
                for cls in klasses if cls.__name__.endswith('View')}
        result[module_name] = klasses
    return result


def serve_static_file(request, path, root, max_age=None, download=False, attachment_filename=None, include_body=True):
    '''to compress content:
    1. put GZipFilter before StaticFileView
    2. use mod_negotiation, mod_deflate only support gzip on every request.
    '''

    path = path.replace("\\", "/").replace(':', '')
    if os.path.sep != "/":
        path = path.replace("/", os.path.sep)
    from .utils.ioutil import safe_join
    try:
        abspath = safe_join(root, path)
    except ValueError:
        raise HttpError(403, "%s is not in root static directory", path)
    if not os.path.exists(abspath):
        raise HttpError(404)
    if not os.path.isfile(abspath):
        raise HttpError(403, "%s is not a file", path)

    response = HttpResponse()

    if max_age:
        response.set_header("Expires", datetime.datetime.utcnow() + \
                                   datetime.timedelta(seconds=max_age))
        response.set_header("Cache-Control", "max-age=" + str(max_age))

    def get_etag(path):
        statobj = os.stat(path)
        return '"{0}-{1}"'.format(statobj.st_mtime, statobj.st_size)

    etag = get_etag(abspath)
    if request.environ.get('HTTP_IF_NONE_MATCH') == etag:
        # 304 response should also carry max-age info.
        response.status_code = 304
        return response

    response.set_header('ETag', etag)

    import mimetypes
    content_type = mimetypes.guess_type(abspath)[0] or 'application/octet-stream'
    response.content_type = content_type

    if download:
        attachment_filename = attachment_filename or os.path.basename(abspath)
        response.headers.add_header('Content-Disposition', 'attachment', filename=attachment_filename)

    if include_body:
        with open(abspath, "rb") as f:
            response.content = f.read()

    return response
